一、概述
红黑树是自平衡的二叉搜索树,是计算机科学中的一种数据结构。
平衡是指所有叶子的深度基本相同(完全相等的情况并不多见,所以只能趋向于相等) 。
二叉搜索树是指,节点最多有两个儿子,且左子树中所有节点都小于右子树。
树中节点有改动时,通过调整节点顺序(旋转),重新给节点染色,使节点满足某种特殊的性质来保持平衡。
旋转和染色过程肯定经过特殊设计可以高效的完成。
它不是完全平衡的二叉树,但能保证搜索操作在O(log n)的时间复杂度内完成(n是树中节点总数)。
插入、删除以及旋转、染色操作都是O(log n)的时间复杂度。
每个节点只需要用一位(bit)保存颜色(仅为红、黑两种)属性,除此以外,红黑树不需要保存其他信息,
所以红黑树与普通二叉搜索树(BST)的内存开销基本一样,不会占用太多内存。
二、性质
上图是一棵普通的红黑树
除了二叉树的基本要求外,红黑树必须满足以下几点性质。
- 节点必须是红色或者黑色。
- 根节点必须是黑色。
- 叶节点(NIL)是黑色的。(NIL节点无数据,是空节点)
- 红色节点必须有两个黑色儿子节点。
- 从任一节点出发到其每个叶子节点的路径,黑色节点的数量是相等的。
这些约束使红黑树具有这样一个关键属性:从根节点到最远的叶子节点的路径长与到最近的叶子节点的路径长度相差不会超过2。 因为红黑树是近似平衡的。
另外,插入、删除和查找操作与树的高度成正比,所以红黑树的最坏情况,效率仍然很高。(不像普通的二叉搜索树那么慢)
解释一下为什么有这样好的效果。注意性质4和性质5。假设一个红黑树T,其到叶节点的最短路径肯定全部是黑色节点(共B个),最长路径肯定有相同个黑色节点(性质5:黑色节点的数量是相等),另外会多几个红色节点。性质4(红色节点必须有两个黑色儿子节点)能保证不会再现两个连续的红色节点。所以最长的路径长度应该是2B个节点,其中B个红色,B个黑色。
最短的路径中全部是黑色节点,最长的路径中既有黑色又有红色节点。
因为这两个路径中黑色节点个数是一样的,而且不会出现两个连续的红色节点,所以最长的路径可能会出现红黑相间的节点。也就是说,树中任意两条路径中的节点数相差不会超过一倍。
比如下图:
三、类比四阶的B树
将之前的红黑树看成B树,就是现在这个样子。
红黑树可以看作是,每个节点簇包含1到3个关键值(Key)的四阶B树,所以就有2到4个子节点的指针。
这个B树中每个节点簇中包含左键(LeftKey)、 中键(MidKey)、右键(RightKey),中键(MidKey)与红黑树中的黑色节点对应,另外两个左右键(LeftKey,RightKey)与红黑树中的红色节点对应。
还可以把此图看成是红色节点向上移动一个高度的红黑树。所以红色节点就与它黑色的父亲节点平行,组成一个 B树节点簇。
这时,会发现在B树中,所有红黑树的叶子节点都神奇的达到了相同的高度。
红黑树的结构与4阶B树(最少1个Key,最多3个Key的B树)是相同的。
4阶B树与红黑树的对应转换关系(图片引用自LLRB):
- B树的节点簇有一个Key值,包含两个子节点指针;对应红黑树中的一个黑色节点。
- B树的节点簇有三个Key值,包含四个子节点指针;中键对应红黑树中的黑色节点,左右键为中键的红色子节点。
- B树的节点簇有三个Key值,包含四个子节点指针;中键对应红黑树中的黑色节点,左右键为中键的红色子节点。
通过4阶B树可以很容易理解红黑树的插入、删除操作。
任一点插入B树,插入点肯定落在叶子节点簇上。如果节点簇有空间,那么插入完成;如果没有空间,则从当前节点簇中选出一个空闲的键值,将其放入父节点簇中。
从B树中删除任一点的问题,可以只考虑删除最大键值或者删除最小键值的情况。原因可以参考二叉搜索树的删除操作。
所以删除时,删除点也会落在叶子节点簇上。如果节点簇还有剩余键值,那么删除完成;如果节点簇没有剩余节点,则从其父节点簇中选出任一键值补充至当前节点簇。然后在父节点递归进行删除操作。
简单来说,删除或插入节点时,所做的调整操作都是为了保持4阶B树的总体高度是一致的。
四、操作
红黑树的查找操作与二叉搜索树BST完全一致。但是插入和删除算法会破坏红黑树的性质。所以对红黑树执行删除、插入操作后需要调整使其恢复红黑树性质。调整过程仅需要少量的染色(O(log n) 或者 O(1)的复杂度)和至多3次的旋转操作(插入仅需2次)。虽然这样会使插入、删除操作很复杂,但其时间复杂度仍然在O(log n)以内。
建议不看下文描述的情况下,先在自己脑海中思考一下插入、删除操作后,如何调整树节点使其保持平衡状态(对应4阶B树的形状进行调整)。
有了自己的想法后,再对照文章的描述,会有更清晰的理解。
图示左旋(Left rotation)右旋(Rgith rotation)
1.插入
插入操作与二叉搜索树一样,新节点肯定会作为树中的叶子节点的儿子加入(详见二叉搜索树相关说明),不过为了恢复红黑树性质,还需要做些染色、旋转等调整操作。另外需要注意的是,红黑树叶子节点是黑色的NIL节点,所以一般用带有两个黑色NIL儿子的新节点直接替换原先的NIL叶子节点,为了方便后续的调整操作,新节点都是默认的红色。
注:插入节点后的调整操作,主要目的是保证树的总体高度不发生改变(使插入点为红色进入树中);如果一定要改变树的高度(插入点无法调整为红色),那么所有操作的目的是使树的整体高度增长1个单位,而不是仅某一子树增长1个高度。
具体如何进行调整要看新节点周围的节点颜色进行处理。下面是需要注意的几种情况:
- 性质3(所有的叶子节点都是黑色)不会被破坏,因为叶子节点全部是黑色的NIL。
- 性质4(红色节点的两个儿子必须是黑色)仅在添加一个红色节点时,将黑色节点染成红色时,或者进行旋转操作时发生改变。
- 性质5(从任一节点出发到叶子节点的路径中黑色节点的数量相等)仅在添加黑色节点时,将红色节点染成黑色时,或者进行旋转操作时发生改变。
注意:我们使用New表示当前新插入的红色节点,Parent表示N的父亲节点,Grandparent表示N的爷爷节点,Uncle表示N的叔叔节点。另外,插入过程会发生递归循环(见case3),所以刚才定义的节点角色并不会绝对固定于某一点,会根据情况(case)进行交换,但每个情况(case)的调整过程,角色肯定保持不变。
后面的图示说明中,节点的颜色都与具体case相关。三角形一般表示未知深度的子树。顶部带有一个小黑点的三角形表示子树的根是黑色,否则子树的根是不确定的颜色。
每种case都使用C语言代码展示。使用下面的节点获取叔叔节点与爷爷节点。
struct node *grandparent(struct node *n) { if ((n != NULL) && (n->parent != NULL)) return n->parent->parent; else return NULL; }struct node *uncle(struct node *n) { struct node *g = grandparent(n); if (g == NULL) return NULL; // No grandparent means no uncle if (n->parent == g->left) return g->right; else return g->left; }
Case1:当前节点N是树中的根节点的情况。这时,将节点直接染成黑色以满足性质2(根节点是黑色)。
由于N是根节点,所以这样肯定也不会破坏性质5(从任一节点出发到叶子节点的路径中黑色节点的数量相等)。
void insert_case1(struct node *n) { if (n->parent == NULL) n->color = BLACK; else insert_case2(n); }
Case2:当前节点的父亲P是黑色的情况。这时,性质4(红色节点必须有两个黑色儿子节点)不会被破坏。性质5(从任一节点出发到其每个叶子节点的路径,黑色节点的数量是相等的)也仍然满足,因为节点N是红色,但N还有两个黑色的叶子节点NIL,所有通过N的路径上,仍然保持和原来相同的黑色节点个数。
void insert_case2(struct node *n) { if (n->parent->color == BLACK) return; /* Tree is still valid */ else insert_case3(n); }
Case3:当前节点的父亲P和叔叔U都是红色的情况。这时,将P、U都染成黑色,而G染成红色以满足性质5(从任一节点出发到其每个叶子节点的路径,黑色节点的数量是相等的)。现在,当前的红色节点N有一个黑色的父亲,而且所有经过父亲和叔叔节点的路径仍然保持与原来相同的节点个数。但是爷爷节点G可能会违反性质2(根节点必须是黑色)或者性质4(红色节点必须有两个黑色儿子节点)(在G节点的父亲也是红色节点时,会破坏性质4)。要修复这个问题,可以对节点G递归执行Case1的操作(可以这样理解,把G当作是新插入的红色节点,对G执行调整操作。因为G的两个子树是平衡的)。这里是尾递归调用,所以也可以使用循环的方法实现。因为这之后肯定会执行一次旋转操作,而且肯定提常数级的旋转次数。
注:因为P是红色的,所以N肯定还有一个爷爷节点G。如果N没有爷爷节点,那P节点就是根节点,应该是黑色才对。由此可见,N还会有一个叔叔节点U,但U也可能是叶子节点(NIL),具体情况见Case4和Case5
void insert_case3(struct node *n) { struct node *u = uncle(n), *g; if ((u != NULL) && (u->color == RED)) { n->parent->color = BLACK; u->color = BLACK; g = grandparent(n); g->color = RED; insert_case1(g); } else { insert_case4(n); } }
Case4:父亲P是红色,叔叔U是黑色,并且N是P的右孩子,P是G的左孩子的情况。
这时,对节点P执行左旋操作,使P变成N的左孩子,N变成G的左孩子,也就是说进入了Case5 的情况。
旋转操作完成之后,性质4(红色节点必须有两个黑色儿子节点)仍然不满足。而性质5(从任一节点出发到其每个叶子节点的路径,黑色节点的数量是相等的)是仍然保持的,因为旋转操作使节点G出发到子树1的路径上多了一个节点N,G到子树2的路径上多了一个节点P,G到子树3的路径上少了一个节点P,而且P、N是红色,不会影响路径中黑色节点的数量。
由于旋转操作后,性质4(红色节点必须有两个黑色儿子节点)仍然不满足,所以我们直接进入Case5处理。
注:
Case4的主要目的就是将当前情况转换到Case5进行处理。
Case4的说明和图示中,我们仅提到了N是右孩子,P是左孩子的情况;另外N是左孩子,P是右孩子的情况没有说明。因为这两种情况处理方法是相似的。不过在C代码中包括了两种情况的处理。
void insert_case4(struct node *n) { struct node *g = grandparent(n); if ((n == n->parent->right) && (n->parent == g->left)) { rotate_left(n->parent); /* * rotate_left can be the below because of already having *g = grandparent(n) * * struct node *saved_p=g->left, *saved_left_n=n->left; * g->left=n; * n->left=saved_p; * saved_p->right=saved_left_n; * * and modify the parent's nodes properly */ n = n->left; } else if ((n == n->parent->left) && (n->parent == g->right)) { rotate_right(n->parent); /* * rotate_right can be the below to take advantage of already having *g = grandparent(n) * * struct node *saved_p=g->right, *saved_right_n=n->right; * g->right=n; * n->right=saved_p; * saved_p->left=saved_right_n; * */ n = n->right; } insert_case5(n); }
Case5:父亲P是红色,但叔叔U是黑色, N是左孩子,P也是左孩子的情况。
此时,对节点G执行一次右旋。使P成为N和G的父节点。已知G是黑色(P是红色,为了不破坏性质4(红色节点必须有两个黑色儿子节点),G肯定是黑色),所以将G染成红色,P染成黑色。此时,既满足性质4(红色节点必须有两个黑色儿子节点),也满足性质5(从任一节点出发到其每个叶子节点的路径,黑色节点的数量是相等的)。唯一的改变是原来经过G节点的路径,现在全部都会经过P节点。
void insert_case5(struct node *n) { struct node *g = grandparent(n); n->parent->color = BLACK; g->color = RED; if (n == n->parent->left) rotate_right(g); else rotate_left(g); }
注:到此为止,插入操作的调整都结束了。
2.删除
注:理解删除操作的重点是,黑色节点删除后,儿子节点中有红色的则从儿子树中选一节点填补被删除后的空缺;否则,从兄弟子树中选择一个节点填补空缺;再否则,就将问题递归到父亲节点处理。跟继承皇位的办法相似
在普通二叉搜索树中删除一个含有两个非叶子儿子的节点时,我们会先找到此节点左子树中最大的节点(也叫前驱),或者右子树中的最小节点(也叫后继),将找到的节点值替换到当前被删除节点的位置,然后删除前驱或者后继节点(详见这里)。这里被删除的节点,至多有一个非叶子节点。因为替换节点值的操作不会破坏红黑树的性质,所以删除红黑树任一节点的问题就简化为,删除一个含有至多一个非叶子儿子的情况。
后面的讨论过程中,我们将这个被删除的节点(含至多一个非叶子儿子)标记为M。M唯一的一个非叶子儿子我们称之为C,如果M的儿子都是叶子节点,那么两个叶子都可称为C,不做区分。
如果M是红色节点,只要用儿子C直接替换到M的位置即可(这仅发生在M有两个叶子节点的情况,因为假设M有一个黑色的儿子CL,CL不是叶子节点,所以CL还有两个黑色的叶子CLL、CLR,M的另外一个儿子是叶子节点CR。那么M节点违反性质5(从任一节点出发到其每个叶子节点的路径,黑色节点的数量是相等的),所以C是黑色时肯定是叶子)。因为原来经过被删除的红色节点的所有路径中,仅少了一个红色节点,且M的父亲和儿子肯定是黑色,所以性质3(叶节点(NIL)是黑色的)和性质4(红色节点必须有两个黑色儿子节点)和性质5(从任一节点出发到其每个叶子节点的路径,黑色节点的数量是相等的)不受影响,
还有一种简单的情况是,M是黑色,C是红色时,如果只是用C替换到M的位置,可能会破坏性质4(红色节点必须有两个黑色儿子节点)和性质5(从任一节点出发到其每个叶子节点的路径,黑色节点的数量是相等的)。所以,只要再把C染成黑色,那么原有性质全部不受影响。
比较复杂的情况是M和C都是黑色时(这仅发生在M有两个叶子的情况,具体原因上一段已经说明)。仍然将C节点直接替换到M的位置,不过我们将位置新位置的C称为N,N的兄弟节点(以前是M的兄弟)称为Sibling。在下面的图示中,我们会使用P表示N的父亲(以前是M的父亲),SL表示S的左儿子,SR表示S的右儿子(S肯定不是叶子节点,因为M和C是黑色,所以P的儿子节点中,M所在子树高度为2,所以S所在子树高度也是2,所以S肯定不是叶子)。
注:下面各种情况中,我们可能交换(改变)各个节点的角色。但在每种情况处理中角色名称是固定不变的。
图示中的节点不会覆盖所有可能的颜色,只是为了方便描述任举一例。白色节点表示未知的颜色(可能是红色也可能是黑色) 。
使用此函数获取兄弟节点
struct node *sibling(struct node *n) { if (n == n->parent->left) return n->parent->right; else return n->parent->left; }
Note: In order that the tree remains well-defined, we need that every null leaf remains a leaf after all transformations (that it will not have any children). If the node we are deleting has a non-leaf (non-null) child N, it is easy to see that the property is satisfied. If, on the other hand, N would be a null leaf, it can be verified from the diagrams (or code) for all the cases that the property is satisfied as well.
下面的代码用来处理刚才说的几种简单情况。函数replace_node()将节点child替换到节点n的位置(替换值,而不改变颜色)。另外,为了操作方便,下面的代码中使用一个真实的Node表示叶子节点(不是用NULL表示叶子)。这种表示方法不影响之前处理插入操作的代码。
void delete_one_child(struct node *n) { /* * Precondition: n has at most one non-null child. */ struct node *child = is_leaf(n->right) ? n->left : n->right; replace_node(n, child); if (n->color == BLACK) { if (child->color == RED) child->color = BLACK; else delete_case1(child); } free(n); }
Note: If N is a null leaf and we do not want to represent null leaves as actual node objects, we can modify the algorithm by first calling delete_case1() on its parent (the node that we delete, n
in the code above) and deleting it afterwards. We can do this because the parent is black, so it behaves in the same way as a null leaf (and is sometimes called a 'phantom' leaf). And we can safely delete it at the end as n
will remain a leaf after all operations, as shown above.
如果N和它原来的父亲(M)都是黑色,那么删除操作会使所有经过N节点的路径都缺少一个黑色节点。因为性质5(从任一节点出发到其每个叶子节点的路径,黑色节点的数量是相等的)被破坏,树需要进行调整以保持平衡。下面详细说一下需要考虑的几种情况。
Case1:N是根节点。此时什么也不需要做。因为每条路径都少了一个黑色节点,而且根是黑色的,所以所有性质都没有被破坏。
void delete_case1(struct node *n) { if (n->parent != NULL) delete_case2(n); }
注:在情况2、5、6中,我们都假定N是P的左儿子。对于N是右儿子的情况,处理方法也不复杂,只要将左右对调就行了。在示例代码中允份考虑了这些情况。
Case2:S是红色。
这时,我们交换P和S的颜色,然后对P执行左旋操作,使S成为N的爷爷。注意,P节点肯定是黑色,因为P的儿子S是红色。此时,所有路径上的黑色节点个数没有变化,而N节点现在的兄弟SL变成了黑色,N节点现在的父亲P变成了红色。接下来,我们可以交给Case4、5、6继续处理。在下面的情况中,我们会将N的新兄弟SL仍然称做S。
void delete_case2(struct node *n) { struct node *s = sibling(n); if (s->color == RED) { n->parent->color = RED; s->color = BLACK; if (n == n->parent->left) rotate_left(n->parent); else rotate_right(n->parent); } delete_case3(n); }
Case3:P、S和S的儿子都是黑色的情况。
直接将S染成红色。这样恰好使经过S点的路径上也少一个黑色节点,而经过N节点的路径由于之前的删除操作,现在也是少一个黑色节点的状态。顺其自然的,S、N是P的儿子,所以现在经过P点的路径相比原来少了一个黑色节点。这么做相当于,把原先存在于N节点的不平衡状态上移到了P节点,现在P节点不满足性质5(从任一节点出发到其每个叶子节点的路径,黑色节点的数量是相等的)。我们可以将P点交给Case1处理,这样就形成一个递归。
void delete_case3(struct node *n) { struct node *s = sibling(n); if ((n->parent->color == BLACK) && (s->color == BLACK) && (s->left->color == BLACK) && (s->right->color == BLACK)) { s->color = RED; delete_case1(n->parent); } else delete_case4(n); }
Case4:S和S的儿子是黑色,但P是红色的情况。
此时,只要把P和S点的颜色互换一下,即可使树恢复平衡状态。原先经过S点的路径,黑色节点数量仍然保持不变;而原先经过N点的路径,现在多了一个黑色节点P,正好弥补了删除节点M后缺少一个黑色节点的问题。
void delete_case4(struct node *n) { struct node *s = sibling(n); if ((n->parent->color == RED) && (s->color == BLACK) && (s->left->color == BLACK) && (s->right->color == BLACK)) { s->color = RED; n->parent->color = BLACK; } else delete_case5(n); }
Case5:P点颜色任意,S点是黑色,S左儿子是红色,右儿子是黑色。N是P的左儿子(S是P的右儿子)的情况。
此时,我们对S点执行右旋转,使得S的左儿子SL,既是S的父亲,也是N的兄弟。同时交换S和SL的颜色,这样所有路径中黑色节点的数量没有变化。
现在N点的兄弟节点S就有了一个红色的右儿子,因为我们可以直接进入Case6处理。
这次转换对于P和N点没有什么影响。(需要再次说明的是,Case6中,我们把N的新兄弟仍然称为S)
void delete_case5(struct node *n) { struct node *s = sibling(n); if (s->color == BLACK) { /* this if statement is trivial, due to case 2 (even though case 2 changed the sibling to a sibling's child, the sibling's child can't be red, since no red parent can have a red child). */ /* the following statements just force the red to be on the left of the left of the parent, or right of the right, so case six will rotate correctly. */ if ((n == n->parent->left) && (s->right->color == BLACK) && (s->left->color == RED)) { /* this last test is trivial too due to cases 2-4. */ s->color = RED; s->left->color = BLACK; rotate_right(s); } else if ((n == n->parent->right) && (s->left->color == BLACK) && (s->right->color == RED)) {/* this last test is trivial too due to cases 2-4. */ s->color = RED; s->right->color = BLACK; rotate_left(s); } } delete_case6(n); }
Case6:P点颜色任意,S点是黑色,S的右儿子是红色。N是P的左儿子(S是P的右儿子)的情况。
此时,我们对P点执行左旋转,使S成为P的父亲(同时还是SR的父亲)。
同时,交换P、S的颜色,并将SR染成黑色。此时S节点的左右子树恢复了平衡,且与删除节点M前有相同的黑色节点数。性质4(红色节点必须有两个黑色儿子节点)和性质5(从任一节点出发到其每个叶子节点的路径,黑色节点的数量是相等的)也已经恢复正常。
无论P节点先前是什么颜色,N都比之前多了一个黑色祖先。假设P先前是红色,现在P被染成了黑色;假设P先前就是黑色,现在P又多了一样黑色的父节点S,所以经过N的路径中,增加了一个黑色节点。
同时,还要说明一下不经过N点的路径的变化情况,一共有两种可能:
- 经过N的新兄弟SL(即图中标为3的子树)的路径。无论是调整前,还是调整后,经过SL点的路径都会经过S点和P点。因为调整操作仅仅是将P、S互换的颜色和位置,所以这条路径中黑色节点的数量没有变化。
- 经过N的新叔叔SR(S的右儿子,现在为黑色)的路径。调整前,此路径会经过红色的SR、黑色的S及颜色未知的P(取S的父亲)。调整后,此路径仅经过黑色的SR(由红色染成黑色)、颜色未知的S(与P互换颜色)。因为路径长度仅计算黑色节点,所以这条路径中黑色节点数量没有变化。
综上,这些路径中黑色节点数量都没有改变。因为,我们修复了性质4(红色节点必须有两个黑色儿子节点)和性质5(从任一节点出发到其每个叶子节点的路径,黑色节点的数量是相等的)。图示中的白色节点可以是任意颜色(红或黑),只要调整前后保持一致即可。
void delete_case6(struct node *n) { struct node *s = sibling(n); s->color = n->parent->color; n->parent->color = BLACK; if (n == n->parent->left) { s->right->color = BLACK; rotate_left(n->parent); } else { s->left->color = BLACK; rotate_right(n->parent); } }
需要强调的是,这里的函数使用的是尾部递归,所以算法是原地算法。上面的算法中,除了删除算法的Case3以外,所有case都是按次序执行。this is the only case where an in-place implementation will effectively loop (after only one rotation in case 3).
另外,尾递归不会发生在儿子节点,一般都是从儿子节点向上,也就是向父亲节点递归。而且递归次数不会超过O(log n)次(n是删除节点前的节点总数)。只要在Case2中发生旋转操作(这也是Case1到Case2间的循环过程中唯一可能发生的旋转),N的父亲就会变成红色,所以循环会立即停止。因此循环过程最多发生一次旋转。退出循环后最多发生两次旋转(Case5、Case6中)。也就是说,红黑树的删除操作,总共不会超过3次旋转。